65장. 실전 2 — 장애 티켓 하나로 문제 해결하기
64장에서 결제 재시도를 배포했다.
배포 이틀 뒤, 새벽 두 시.
[알림] PG 중복 결제 의심 건 감지 — 4건
64장 끝에서 “놓친 것이 하나 있다” 고 했다.
그것이 여기서 드러난다.
02:14 — 상황 판단 (사람)
알림을 보고 가장 먼저 하는 일은
Agent를 부르는 것이 아니다.
1. 지금도 발생 중인가
2. 몇 건인가
3. 돈이 실제로 이중 청구됐는가
대시보드에서 확인했다.
중복 결제 의심: 4건
전부 최근 3시간 내
지금도 발생 중 ⚠️
사람이 결정한 것 ① — 즉시 완화.
결제 재시도 피처 플래그 off
56장에서 말한 대로 완화가 먼저다.
플래그를 끄는 것은 Agent에게 맡기지 않는다.
59장의 목록에 있는 항목이다.
02:19 — 조사 위임
완화가 됐으니 이제 조사다.
incident-responder 로 조사해줘.
증상:
- PG 중복 결제 의심 4건, 최근 3시간
- 어제 결제 재시도 기능 배포 (PAY-2841)
- 재시도 플래그는 방금 off 함
수정은 하지 마. 원인 가설과 확인 방법만.
56장에서 만든 읽기 전용 Agent다.tools 에 Edit 이 없다.
내가 이 사이에 한 일은
고객센터에 상황 공유였다.
🔥 조사와 공지가 병렬로 진행됐다.
혼자 온콜을 서면서 둘을 동시에 하기 어려운데,
위임하면 가능해진다.
02:31 — 조사 결과
## 현재 상태
플래그 off 이후 추가 발생 없음.
## 확인한 사실
- 4건 모두 PG 타임아웃 후 재시도된 건 (payment_logs)
- 4건 모두 멱등키가 원 요청과 다름 ⚠️
- PaymentPgClient 는 멱등키를 그대로 전달함 (PaymentPgClient.kt:38)
- 그런데 재시도는 PaymentService.pay() 에 걸려 있음
(PaymentService.kt:44 @Retryable)
## 가설 (유력한 순)
1. 재시도 지점이 PaymentService 라서 PayRequest 가
새로 생성되며 멱등키도 새로 발급된다
→ 맞다면 재시도 요청의 키가 매번 달라야 함. 4건 모두 그렇다
2. PG 쪽 멱등성 시간창 문제 (가능성 낮음)
3. 클라이언트 중복 요청 (로그상 단일 요청)
## 확인하지 못한 것
- 정산 배치 영향 (배치는 03:00 실행 예정)
## 권장 조치
즉시: 플래그 off 유지 (완료)
근본: 재시도를 PgClient 레벨로 이동
⚠️ 1번 가설이 정확했다.
64장의 계획서에는 래퍼 클래스에 재시도를 붙이기로 되어 있었는데,
구현에서 PaymentService 레벨에 붙었다.
계획과 구현이 어긋난 것이다.
02:35 — 확인하지 못한 것 처리 (사람)
보고서 마지막 항목이 눈에 걸렸다.
정산 배치가 03:00 에 실행 예정
25분 뒤다.
사람이 결정한 것 ② — 배치 일시 중단.
🔥 19장에서 확인하지 못한 것 절을
반드시 쓰라고 한 이유가 여기서 나온다.
이 항목이 없었으면
25분 뒤에 두 번째 장애가 났을 수 있다.
09:00 — 재현 테스트
급한 불은 껐다. 수정은 아침에 한다.
56장의 순서대로다.
어젯밤 장애를 재현하는 테스트를 먼저 만들어줘.
PG 타임아웃 → 재시도 시 멱등키가 원 요청과 같아야 한다.
지금은 실패해야 정상이야.
@Test
fun `재시도 시 멱등키는 원 요청과 동일하다`() {
mockPg.enqueue(timeout())
mockPg.enqueue(success())
paymentService.pay(request)
val keys = mockPg.recordedRequests.map { it.idempotencyKey }
assertThat(keys.distinct()).hasSize(1) // 실패: 2
}
> ./gradlew test --tests '*PaymentRetryTest'
재시도 시 멱등키는 원 요청과 동일하다 FAILED
expected size: 1 but was: 2
원인이 확정됐다.
09:20 — 수정
재시도 지점을 PaymentPgClient.request() 로 옮겨줘.
계획서(@tasks/PAY-2841.md)의 원래 설계대로.
PaymentService 의 @Retryable 은 제거해줘.
수정 후 테스트가 통과했다.
09:40 — 왜 통과했는가
62장의 질문이다.
"누가 잘못했는가" 가 아니라 "왜 통과했는가"
세 층에서 놓쳤다.
| 층 | 놓친 이유 |
|---|---|
| 테스트 | 멱등키 동일성을 검증하지 않았다 |
| 계획 대조 | Review에서 “계획대로인가” 를 형식적으로 봤다 |
| 아키텍처 | 재시도 위치를 강제하는 규칙이 없었다 |
⚠️ 첫 번째가 핵심이다.
64장의 완료 조건에 이런 항목이 있었다.
- 같은 멱등키로 재시도해도 결제 1건 테스트
이 테스트는 통과했다.
같은 멱등키를 명시적으로 넘겼을 때
결제가 1건인지만 봤기 때문이다.
재시도가 멱등키를 유지하는지는 검증하지 않았다.
🔥 30장에서 말한 그대로다.
실패 주입 테스트가 없으면
복원력 코드는 검증된 적 없는 코드다.
테스트는 있었지만 주입 지점이 틀렸다.
10:10 — 하네스를 고친다
세 가지를 추가했다.
1. 테스트 (23장)
@Test fun `재시도 시 멱등키는 원 요청과 동일하다`()
@Test fun `재시도는 PgClient 레벨에서만 일어난다`()
2. Skill 체크리스트 (48장)
# .claude/skills/add-external-call/SKILL.md
- [ ] 재시도가 붙은 지점이 요청 객체 생성 지점보다 안쪽인가
(밖에 붙으면 멱등키가 매번 새로 생성된다 — PAY-2841 장애)
3. 아키텍처 테스트 (42장)
@Test
fun `@Retryable 은 Infrastructure 계층에만 붙는다`() { ... }
⚠️ 세 번째가 가장 강하다.
문장이 아니라 조건이 됐다.
같은 실수가 다시 나오면 CI에서 막힌다.
10:30 — 기록
48장의 장애 분석 Skill 7번 항목이다.
# tasks/incident-2026-08-16.md
## 타임라인
02:14 알림 → 플래그 off 02:19 조사 위임
02:31 원인 확정 02:35 정산 배치 중단
09:40 근본 수정 10:10 하네스 개선 3건
## 원인
재시도가 PaymentService 레벨에 붙어 재시도마다 PayRequest 가
새로 생성되고 멱등키도 새로 발급됨. 계획서 설계와 구현이 어긋남.
## 왜 통과했는가
1. 멱등성 테스트가 "같은 키를 주면 1건" 만 검증
2. Review에서 계획 대조가 형식적이었음
3. 재시도 위치를 강제하는 규칙 없음
## 조치
즉시: 플래그 off, 배치 중단 / 근본: 재시도 지점 이동
하네스: 테스트 2건, Skill 항목 1개, 아키텍처 테스트 1건
## 확인하지 못한 것
- 중복 결제 4건의 환불 처리 (CS팀 진행 중)
- 정산 배치 재실행 시 영향 (내일 확인)
19장의 형식 그대로다.
⚠️ 마지막 절을 비우지 않았다.
급하게 처리한 장애일수록
미확인 항목이 많다.
정리
장애 인지 → 완화 5분
조사 (위임) 12분
2차 장애 예방 그 사이 사람이
근본 수정 다음 날 오전
하네스 개선 30분
사람이 결정한 것은 둘이었다.
① 즉시 플래그 off
② 정산 배치 중단
둘 다 되돌릴 수 없거나
지금 당장 해야 하는 일이었다.
🔥 그 사이 조사는 Agent가 했다.
56장에서 말한 병렬이
실제로 새벽 두 시에 이렇게 작동한다.
이 장의 핵심
- 알림을 받고 가장 먼저 하는 일은 Agent 호출이 아니라 완화다
- 플래그를 끄는 것은 사람이 한다 — 되돌릴 수 없는 실행이다
- 조사를 위임하면 사람이 공지와 판단에 집중할 수 있다
확인하지 못한 것절이 25분 뒤의 2차 장애를 막았다- 계획서와 구현이 어긋난 것을 조사 과정에서 발견했다
- “같은 키를 주면 1건” 테스트는 통과했지만 주입 지점이 틀렸다
- 실패 주입 테스트가 있어도 지점이 틀리면 검증되지 않은 코드다
- 질문은 “누가 잘못했는가” 가 아니라 “왜 통과했는가” 다
- 하네스 개선 세 건 중 아키텍처 테스트가 가장 강하다 — 조건이 됐다
- 급한 장애일수록 미확인 항목을 반드시 남긴다